iT邦幫忙

2024 iThome 鐵人賽

DAY 28
1
Software Development

可以Go一輩子嗎?系列 第 28

專案練習: 字幕搜尋API (2/4)

  • 分享至 

  • xImage
  •  

專案練習: 字幕搜尋API (2/4)

FFmpeg介紹

在開始今天的文章前我想先介紹一下世界上最好用的影音處理工具之一 - FFmpeg,現今你在市面上看到的大部分功能都可以透過FFmpeg來實現(甚至某些軟體都是FFmpeg套一層皮,如: 格式工廠等)

FFmpeg支援的格式非常多,幾乎可以說是所有的格式都支援,並且本身支援多平台,所以你可以在Windows、Linux、MacOS上使用FFmpeg,所以你可以在官網上下載到最新的版本。
FFmpeg有三個主要的工具,分別是ffmpegffplayffprobe,其中

  • ffmpeg:用於對視訊檔案或音訊檔案轉換格式
  • ffplay:一個簡單的播放器,基於 SDL 與 FFmpeg 函式庫
  • ffprobe:用於顯示媒體檔案的資訊,見 MediaInfo

接下來的操作都是以ffmpeg為主,ffprobe只會在需要查看影片資訊時使用

FFmpeg參數介紹

  • -h: 顯示幫助
  • -i <filename>: 輸入檔案
  • -ss <time>: 指定起始時間(time: "HH:MM:SS.xxx", 其中HH代表小時, MM代表分鐘, SS代表秒, xxx代表毫秒, 前面補0)
  • -to <time>: 指定結束時間
  • -vframes <number>: 指定要輸出的frame數
  • -t <duration>: 指定持續時間(duration: 秒)
  • -c:v <codec>: 指定視訊編碼器(也可以用-vcodec來指定)

真的記不起來的話可以透過ffmpeg -h來查看,或者是網路上找ffmpeg指令生成器FFmpeg 指令產生器

安裝FFmpeg

在Linux上可以透過以下指令來安裝

sudo apt-get install ffmpeg # Ubuntu, Debian
sudo yum install ffmpeg # CentOS, Fedora
sudo dnf install ffmpeg # Fedora 22以後
sudo pacman -S ffmpeg # Arch Linux, Manjaro

在Windows上可以透過以下指令來安裝,或是去官網下載

choco install ffmpeg

流程說明

首先,如果要擷取特定時間範圍的影片,我們需要指定開始時間與結束時間,可以透過-ss-to來指定,假設我要擷取從第10秒到第20秒的影片,可以透過以下指令來實現

ffmpeg -i input.mp4 -ss 00:00:10 -to 00:00:20 -c copy output.mp4

也可以透過-t來指定持續時間,或是透過-vframes來指定要輸出的frame數,假設我要擷取第10秒到第20秒的影片,可以透過以下指令來實現

ffmpeg -i input.mp4 -ss 00:00:10 -t 10 -c copy output.mp4

而對於圖片的擷取,只需要指定-ss就行了,假設我要擷取第10秒的影片,可以透過以下指令來實現

ffmpeg -i input.mp4 -ss 00:00:10 -vframes 1 output.jpg

然而,我的資料只能抓到各個台詞的起始frame與結束frame,所以我們寫一個function將frame轉換為ffmpeg的時間格式

// convert frame to ffmpeg time format("HH:MM:SS.mmm")
function frame_to_ffmpeg_time(){
    # $1: frame: int
    # $2: fps: float
    # return: "%02d:%02d:%02d.%03d"
    
    frame=$1
    fps=$2
    frame_time=$(echo "scale=3; $frame / $fps" | bc)
    seconds=$(echo $frame_time | cut -d '.' -f 1)
    milliseconds=$(echo $frame_time | cut -d '.' -f 2)
    hours=$(echo $seconds / 3600 | bc)
    minutes=$(echo $seconds / 60 % 60 | bc)
    seconds=$(echo $seconds % 60 | bc)
    printf "%02d:%02d:%02d.%03d" $hours $minutes $seconds $milliseconds
}

frame_to_ffmpeg_time $1 $2

這時可能有人要問了,你的資料只有frame,那fps怎麼來的?這時我們可以透過ffprobe來查看影片的fps

ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 input.mp4
# > 24000/1001

專案資料夾結構

mygo
├── data
│   ├── data.go
│   ├── db.go
│   ├── go.mod
│   └── go.sum
├── data.json
├── main.go
├── go.mod
├── go.sum
└── video
    ├── go.mod
    ├── go.sum
    └── video.go

接下來的流程跟昨天一樣,在mygo資料夾中建立video資料夾,並在video資料夾中初始化module為mygo/video,接著在mygo資料夾的go.mod中加入replace來指定video資料夾的module。

  • mygo/go.mod
module mygo
go <current version>
replace mygo/video => ./video
replace mygo/data => ./data

require mygo/video v0.0.0
require mygo/data v0.0.0

實作

接下來就是透過Go來實作上面的功能了

轉換frame為ffmpeg時間格式

func FrameToTime(frame int, fps float64) string {
	sec := float64(frame) / fps
	min := int(sec / 60)
	hour := int(min / 60)
	sec = sec - float64(min*60)
	min = min % 60
	return fmt.Sprintf("%02d:%02d:%06.3f", hour, min, sec)
}

獲取影片fps

這邊我們透過go-ffprobe來對影片進行解析,並獲取fps

import (
    "context"
    "fmt"
    "log"
    "os"
    "runtime"
    "strconv"
    "time"

    ffprobe "gopkg.in/vansante/go-ffprobe.v2"
)
func FetchVideoFPS(videoPath string) (int, float64) {
	ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelFn()

	data, err := ffprobe.ProbeURL(ctx, videoPath)
	if err != nil {
		log.Panicf("Error getting data: %v", err)
	}
	frame, err := strconv.Atoi(data.Streams[0].NbFrames)
	if err != nil {
		log.Panicf("Error getting frame: %v", err)
	}

	var num, den float64
	fmt.Sscanf(data.Streams[0].RFrameRate, "%f/%f", &num, &den)
	fps := num / den
	return frame, fps
}

至於路徑的部份因人而異,不過建議將檔名改為<episode>.mp4,這樣在後續的操作中會比較方便

const videoPath = "%s/mygo/%s.mp4"
var homePath = os.Getenv("HOME")
func process(){
    episode := "1-3"
    if runtime.GOOS == "windows" {
        homePath = os.Getenv("USERPROFILE")
    }
    frame, fps := FetchVideoFPS(fmt.Sprintf(videoPath, homePath, episode))
}

擷取圖片

從剛才的介紹我們知道可以透過以下指令來擷取圖片

ffmpeg -i "${episode}.mp4" -ss $(frame_to_ffmpeg_time ${frame} ${fps}) -vframes 1 "${episode}.jpg"

但是要怎麼透過Go來執行這個指令呢?這時我們可以透過ffmpeg-go套件來執行,如果你之前有使用過ffmpeg-python的話,這個套件的操作方式跟ffmpeg-python很像

import (
    "fmt"
    "os"
    "os/exec"
    "runtime"

    ffmpeg "github.com/u2takey/ffmpeg-go"
)

func ExtractFrame(episode string, frameNumber int, fps float64) (*bytes.Buffer, error) {
	if frameNumber < 0 {
		return nil, fmt.Errorf("frame number must be positive")
	}
	if runtime.GOOS == "windows" {
		homePath = os.Getenv("USERPROFILE")
	}
	videoPath := fmt.Sprintf("%s/mygo-anime/%s.mp4", homePath, episode)
	fmt.Printf("Extracting frame %d from %s\n", frameNumber, videoPath)

	buf := &bytes.Buffer{}
	err := ffmpeg.Input(videoPath, ffmpeg.KwArgs{"ss": FrameToTime(frameNumber, fps)}).
		Output("pipe:", ffmpeg.KwArgs{"vframes": 1, "format": "image2", "vcodec": "mjpeg"}).
		WithOutput(buf, os.Stdout).
		Run()
	if err != nil {
		return nil, err
	}

	fmt.Printf("Extracted frame %d from %s\n", frameNumber, videoPath)
	return buf, nil
}

附上ffmpeg-python的寫法

from pathlib import Path
from typing import Literal

import ffmpeg
def extract_frame(
    episode: Literal["1-3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"],
    fps: float,
    frame_number: int) -> bytes:
    video_path = Path.home() / "mygo-anime" / f"{episode}.mp4"

    process = ffmpeg.input(
        video_path,
        ss=frame_to_time(frame_number, fps),
    ).output("pipe:", vframes=1, format="image2", vcodec="mjpeg")
    result, _ = process.run(capture_stdout=True)
    return result

擷取GIF

擷取GIF的方式跟擷取圖片的方式很像,只是在Output時要指定formatgif,並且要指定vcodecgif,這樣就可以將影片轉換為GIF了
然後FFmpeg支援反向擷取,因此我們需要先檢查是否要反向擷取,如果要的話就要將-ss-to對調,然後將-vf reverse加入到指令中

ffmpeg -i "${episode}.mp4" -ss $(frame_to_ffmpeg_time ${frame_start} ${fps}) -to $(frame_to_ffmpeg_time ${frame_end} ${fps}) -vf reverse -f gif "${episode}.gif"

除此之外,對於開始跟結束frame相同的情況,我們可以直接rollback到擷取圖片的方式,以節省時間
擷取GIF用Go來實現的話就是

func ExtractGIF(episode string, startFrame, endFrame int, fps float64) (*bytes.Buffer, error) {
	if runtime.GOOS == "windows" {
		homePath = os.Getenv("USERPROFILE")
	}
	videoPath := fmt.Sprintf("%s/mygo-anime/%s.mp4", homePath, episode)
	reverse := false

	if startFrame > endFrame {
		startFrame, endFrame = endFrame, startFrame
		reverse = true
	} else if startFrame == endFrame {
		return ExtractFrame(episode, startFrame, fps)
	}

    startTime := FrameToTime(startFrame, fps)
	endTime := FrameToTime(endFrame, fps)

	buf := &bytes.Buffer{}
	input := ffmpeg.Input(videoPath, ffmpeg.KwArgs{"ss": startTime, "to": endTime})

	if reverse {
		input = input.Filter("reverse", nil)
	}
    process := input.Output("pipe:", ffmpeg.KwArgs{"format": "gif", "vcodec": "gif"}).WithOutput(buf, os.Stdout)
    err := process.Run()
    if err != nil {
        return nil, err
    }
    return buf, nil
}

附上ffmpeg-python的寫法

async def extract_gif(
    episode: Literal["1-3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"],
    start_frame: int,
    end_frame: int,
) -> bytes:
    video_path = Path.home() / "mygo-anime" / f"{episode}.mp4"
    reverse = False

    if start_frame > end_frame:
        start_frame, end_frame = end_frame, start_frame
        reverse = True

    elif start_frame == end_frame:
        return extract_frame(
            episode, start_frame
        )  # fallback to extract single frame

    # process palettegen and paletteuse
    input_stream = ffmpeg.input(
        video_path,
        ss=self._frame_to_time(start_frame, episode_data.frame_rate),
        to=self._frame_to_time(end_frame, episode_data.frame_rate),
    )
    if reverse:
        input_stream = input_stream.filter("reverse")

    
    result, _ = input_stream.output(
        "pipe:", vcodec="gif", format="gif"
    ).run(capture_stdout=True)

    return result

那麼今天的文章就到這告一段落,如果我的文章有任何地方有錯誤請在留言區反應
明天將會把上面的功能串接至API,進而透過API來擷取指定frame的圖片或GIF
time

REF


上一篇
專案練習: 字幕搜尋API (1/4)
下一篇
專案練習: 字幕搜尋API (3/4)
系列文
可以Go一輩子嗎?31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言